StockDetailHeader.tsx 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. 'use client';
  2. import { useCallback, useEffect, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { ArrowLeft } from 'lucide-react';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faStar as faStarSolid } from '@fortawesome/free-solid-svg-icons';
  7. import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons';
  8. import useAuth from '@/hooks/useAuth';
  9. import { fetchApi } from '@/lib/utils/client';
  10. import { StockWatchStatusResponse, StockWatchToggleResponse } from '@/types/stock';
  11. type Props = {
  12. code: string;
  13. name: string;
  14. };
  15. // 종목 상세 상단 바 — 뒤로가기 + 관심종목(☆) 토글. RSC(page.tsx) 안에 마운트되는 client island.
  16. export default function StockDetailHeader({ code, name }: Props)
  17. {
  18. const router = useRouter();
  19. const { isAuthenticated } = useAuth();
  20. const [watched, setWatched] = useState(false);
  21. const [pending, setPending] = useState(false);
  22. // 초기 관심 여부 조회 (로그인 시)
  23. useEffect(() => {
  24. if (!isAuthenticated) {
  25. setWatched(false);
  26. return;
  27. }
  28. let alive = true;
  29. fetchApi<StockWatchStatusResponse>(`/api/stocks/watchlist/status?codes=${encodeURIComponent(code)}`, { method: 'GET', silent: true })
  30. .then(res => {
  31. if (alive && res.success && res.data) {
  32. setWatched(res.data.map?.[code] === true);
  33. }
  34. })
  35. .catch(() => {});
  36. return () => {
  37. alive = false;
  38. };
  39. }, [code, isAuthenticated]);
  40. const handleBack = useCallback(() => {
  41. if (window.history.length > 1) {
  42. router.back();
  43. }
  44. else {
  45. router.push('/stock');
  46. }
  47. }, [router]);
  48. const handleToggle = useCallback(async () => {
  49. if (!isAuthenticated) {
  50. if (confirm('로그인 후 이용 가능합니다.\n로그인하시겠습니까?')) {
  51. window.dispatchEvent(new CustomEvent('auth:unauthorized'));
  52. }
  53. return;
  54. }
  55. if (pending) {
  56. return;
  57. }
  58. setPending(true);
  59. const next = !watched;
  60. setWatched(next);
  61. try {
  62. const res = await fetchApi<StockWatchToggleResponse>(`/api/stocks/${encodeURIComponent(code)}/watch`, { method: 'POST', silent: true });
  63. if (res.success && res.data) {
  64. setWatched(res.data.isWatched);
  65. window.dispatchEvent(new CustomEvent('watchlist:changed'));
  66. }
  67. else {
  68. setWatched(!next);
  69. }
  70. }
  71. catch {
  72. setWatched(!next);
  73. }
  74. finally {
  75. setPending(false);
  76. }
  77. }, [code, isAuthenticated, pending, watched]);
  78. return (
  79. <div className='stock-detail__topbar'>
  80. <button type='button' className='stock-detail__back' onClick={handleBack} aria-label='뒤로 가기'>
  81. <ArrowLeft size={20} aria-hidden />
  82. </button>
  83. <button
  84. type='button'
  85. className={`stock-detail__watch${watched ? ' stock-detail__watch--active' : ''}`}
  86. onClick={handleToggle}
  87. aria-pressed={watched}
  88. aria-label={watched ? `${name} 관심종목 해제` : `${name} 관심종목 추가`}
  89. title={watched ? '관심종목 해제' : '관심종목 추가'}
  90. disabled={pending}
  91. >
  92. <FontAwesomeIcon icon={watched ? faStarSolid : faStarRegular} />
  93. <span>{watched ? '관심' : '관심 추가'}</span>
  94. </button>
  95. </div>
  96. );
  97. }